fix: 서버 클라이언트 hydration 불일치 오류, 시간문제 해결#377
Conversation
아카이브 ui 보완 밸런스 게임 > url 공유 기능 (투표 글 하나에 대해서 외부로 공유 가능하게끔) 밸런스게임에서 프사가 안보임 해결 나의 스터디목록 ui에 답변자 -> 지원자 이렇게 문구 바꿈. 밸런스게임에 태그 필터 기능 추가. 밸런스게임 글 작성에 기존 태그 목록 보여지도록 함. 답변자 -> 지원자. fix: 다중태그 지원 fix: 검색 자동완성 및 ui 다듬기 fix: 복구 fix: ui디자인 변경 어드민 기능 삭제 feat: 스터디 튜토리얼 추가 refactor: 코드리팩토링 refactor: 코드 리팩토링 fix: ui 수정
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthrough다양한 기능을 추가하고 기존 컴포넌트를 리팩토링합니다: API 로깅 시스템 도입, 아카이브/밸런스 게임 필터링 및 검색 기능 확장, 멀티스텝 튜토리얼 오버레이 시스템 추가, 스터디 히스토리 UI 재구성, 공유 기능 통합, 재사용 가능한 UI 컴포넌트 추가. Changes
Sequence DiagramsequenceDiagram
actor User
participant UI as Archive/Balance Game UI
participant Filter as Filter Hook
participant API as API Client
participant Server as Server
participant Cache as React Query Cache
User->>UI: 필터/태그 선택
activate UI
UI->>Filter: 필터 상태 업데이트
activate Filter
Filter->>Filter: 상태 변경 (tags, sort, search)
Filter-->>UI: 새 필터 반환
deactivate Filter
UI->>UI: 검색어 디바운스 (300ms)
UI->>API: 태그/검색 제안 요청
activate API
API->>Server: GET /tags 또는 /suggestions?q=...
activate Server
Server-->>API: 제안 배열 반환
deactivate Server
deactivate API
UI->>UI: 드롭다운 제안 렌더링
User->>UI: 태그 선택 또는 제안 클릭
UI->>Filter: onAddTag() / onRemoveTag()
UI->>UI: 필터 업데이트
UI->>API: useQuery(filters) → getArchiveList/getBalanceGameList
activate API
API->>Server: GET /archive?tags=tag1,tag2&q=search&sort=latest
activate Server
Server-->>API: 필터된 결과 배열
deactivate Server
deactivate API
API->>Cache: 쿼리 키로 캐시 저장
Cache-->>UI: 캐시된 데이터 반환
UI->>UI: 필터된 항목들 렌더링
deactivate UI
sequenceDiagram
actor User
participant Page as Study Page
participant Controller as StudyTutorialController
participant Card as StudyCard
participant Overlay as TutorialOverlay
participant Router as Next.js Router
User->>Router: /home?tutorial=true
Router-->>Page: 튜토리얼 모드 활성화
activate Page
Page->>Controller: 렌더링
activate Controller
Controller->>Controller: searchParams에서 tutorial 확인
Controller->>Controller: stepIndex = 0
Controller->>Card: tutorialMode=true, 시나리오 props
activate Card
Card->>Card: forcedStatus, forcedRole 적용
Card-->>Controller: 렌더링
deactivate Card
Controller->>Overlay: steps, activeIndex, handlers
activate Overlay
Overlay->>Overlay: targetSelector로 요소 찾기
Overlay->>Overlay: spotlight rect 계산
Overlay->>Overlay: tooltip 위치 계산
Overlay-->>Controller: 렌더링
deactivate Overlay
User->>Overlay: ArrowRight / "다음" 클릭
Overlay->>Controller: onStepChange(stepIndex + 1)
Controller->>Controller: stepIndex 증가
Controller->>Card: 새 시나리오 props
Controller->>Overlay: 새 단계 렌더링
User->>Overlay: "완료" 또는 Escape
Overlay->>Controller: onClose()
Controller->>Router: navigate("/home", { scroll: false })
deactivate Controller
deactivate Page
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 20
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
src/features/study/group/channel/ui/sub-comments.tsx (1)
32-32:⚠️ Potential issue | 🟡 Minor디버그용
console.log문을 제거하세요.프로덕션 코드에 디버그 로그가 남아있습니다. 불필요한 콘솔 출력은 성능에 영향을 줄 수 있고 민감한 데이터가 노출될 수 있으므로 제거해야 합니다.
🧹 제거 제안
- console.log(data, 'subcomment');src/components/card/voting-card.tsx (1)
144-149:⚠️ Potential issue | 🟠 Major상대 경로 import 수정 및 조건부 로직 정리 필요
Path alias 위반: 7번 줄의
import VoteTimer from '../voting/vote-timer'를@/components/voting/vote-timer로 변경하세요.사용되지 않는 코드: 145-149번 줄의 조건문은 양쪽 분기가 동일한
cardContent를 반환하므로 정리가 필요합니다.미구현된 Link 래핑: 주석에는 "onClick 없으면 Link로 감싸기"라고 명시되어 있으나 실제로는 구현되지 않았습니다. 현재 community-tab-client.tsx에서 onClick prop 없이 VotingCard를 사용하고 있으므로 카드가 완전히 비활성 상태입니다. Link 래핑을 구현하거나 의도를 명확히 해야 합니다.
src/features/study/one-to-one/schedule/ui/today-study-card.tsx (1)
354-361:⚠️ Potential issue | 🟡 Minor이미지 경로 일관성 문제.
src="icons/Link.svg"가 다른 이미지 경로(/icons/...)와 달리 앞에/가 없습니다. 이로 인해 특정 라우팅 컨텍스트에서 이미지가 로드되지 않을 수 있습니다.🐛 경로 수정 제안
<Image - src="icons/Link.svg" + src="/icons/Link.svg" width={24} height={24} alt="스터디 링크" />
🤖 Fix all issues with AI agents
In `@docs/balance-game-tag-filter-request.md`:
- Around line 44-46: The fenced code block containing the HTTP example "GET
/api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react"
needs a language specifier to satisfy markdownlint MD040; update that fenced
block (the triple-backtick block wrapping the GET request) to use ```http as the
opening fence and keep the closing ``` unchanged so the code block is properly
annotated.
In `@src/api/client/api-logger.ts`:
- Around line 29-81: The request logger currently prints full params and
response bodies (see attachApiLogger, stringifyData, stringifyParams, and the
request/response interceptors); update it to avoid PII by adding an explicit
opt-in flag to attachApiLogger (e.g., enableSensitiveLogging) and only emit full
payloads when that flag is true, implement masking in
stringifyData/stringifyParams to redact known sensitive keys (password, token,
ssn, email, authorization, etc.), and enforce a maximum length/truncation for
strings and arrays to avoid huge dumps; ensure the interceptors use the new flag
and masked/truncated output when logging params and response bodies.
In `@src/components/ui/filters/sort-dropdown.tsx`:
- Around line 30-33: The button in SortDropdown (the <button> element rendering
{icon} and {label} in src/components/ui/filters/sort-dropdown.tsx) lacks an
explicit type, which can cause unintended form submission; update the button
element to include type="button" to prevent default submit behavior when used
inside forms, keeping the existing classes and children unchanged.
In `@src/components/ui/filters/view-mode-toggle.tsx`:
- Around line 33-46: The icon-only buttons in the ViewModeToggle rendering lack
proper button type and accessibility attributes; update the <button> created
inside the map (the element using key={option.value}, onClick={() =>
onChange(option.value)}, option.icon, etc.) to include type="button",
aria-pressed={isActive} to expose toggle state to assistive tech, and an
accessible name via aria-label={option.title} (or aria-label derived from
option.title) so screen readers announce the control.
In `@src/components/ui/modal-shell.tsx`:
- Around line 30-34: Modal.Close에 스크린리더용 레이블이 빠져 있어 접근성이 떨어집니다; Modal.Close (안의
XIcon)를 수정해 aria-label을 명시적으로 추가하세요 — 예: Modal.Close에 aria-label 속성으로 "닫기" 또는
`Close ${title}` 형태의 설명을 전달하여 버튼 목적을 명확히 하며 Modal.Header, Modal.Title,
Modal.Close 식별자를 찾아 해당 컴포넌트에 레이블을 추가하도록 변경하세요.
In `@src/components/ui/stat-item.tsx`:
- Around line 30-46: In the StatItem component replace the invalid Tailwind
utility "gap-25" with a valid spacing token (e.g., "gap-250") or an arbitrary
value (e.g., "gap-[25px]") wherever it's used in the component's className
strings (both the button variant that composes hoverClassName and the div root
variant that composes className); update the className occurrences that include
'gap-25' (the ones surrounding iconClassName, valueClassName, hoverClassName) so
they use the chosen valid utility consistently.
In `@src/components/voting/vote-timer.tsx`:
- Around line 82-98: The placeholder block returned when !isMounted || !timeLeft
currently renders only "00:00:00" which omits the days segment and can cause a
layout shift when timeLeft.days > 0; update the placeholder in vote-timer.tsx
(the branch guarded by isMounted and timeLeft) to include the same days span
structure used in the active timer (i.e., render a days <span> when
timeLeft?.days might be > 0 or always include a days <span> with "00" and the
same classes) so the DOM shape matches the active render (references: isMounted,
timeLeft, timeLeft.days, Timer component) and prevents hydration/layout jumps.
In `@src/components/voting/voting-modal-header.tsx`:
- Around line 23-31: The close button currently attaches onClick={onClose}
inside Modal.Close which causes onClose to be invoked twice because Modal.Root
already triggers onClose via its onOpenChange; remove the onClick prop from the
button inside Modal.Close (leave disabled, className and icon intact) and ensure
modal close logic remains handled only by Modal.Root's onOpenChange handler so
onClose is not called from the button directly.
In `@src/features/study/interview/ui/study-ready-modal.tsx`:
- Around line 42-49: resolvedOpen currently uses forceOpen with nullish
coalescing so passing forceOpen={false} permanently closes the modal; change
behavior to treat forceOpen as "force open only" by deriving resolvedOpen as
(forceOpen === true ? true : isOpen) and update the effect that syncs state so
it only calls setIsOpen(true) when forceOpen === true (leave uncontrolled
behavior when forceOpen is false or undefined). Update references in this
component (resolvedOpen, forceOpen, isOpen, useEffect, setIsOpen, onClose) to
use that logic so the parent can force-open without permanently disabling user
toggles, or alternatively convert the component to a fully controlled pattern
(expose onOpenChange) if you prefer the controlled approach.
In `@src/features/study/one-to-one/archive/ui/archive-grid.tsx`:
- Around line 75-81: The cancel handler handleCancel can set description to null
when item.description is null, which can pass null into the input component;
update the handler to normalize description to an empty string (use the existing
setDescription call with item.description fallback to '') so
setDescription(item.description ?? '') is used (also keep setTitle(item.title)
and setNextPrivate(!!item.isPrivate) unchanged) to ensure the input always
receives a string.
In `@src/features/study/one-to-one/archive/ui/archive-list.tsx`:
- Around line 54-73: The current handleSave calls onUpdate(item.id, request) and
immediately setIsEditing(false), so users get no feedback on failure; change
handleSave to await the update mutation (or use its returned promise) and only
call setIsEditing(false) on success, and on error show user feedback and keep
editing mode; specifically update the handleSave flow around the onUpdate call
(and its returned mutation promise or callbacks in the mutation used by
onUpdate) to: 1) await the mutation result or use onSuccess/onError handlers, 2)
call setIsEditing(false) only on success, and 3) show a toast or set an error
state on failure so the UI retains editing with an error message.
In `@src/features/study/one-to-one/archive/ui/archive-pagination.tsx`:
- Around line 22-35: The previous/next pagination buttons
(PaginationCircleButton wrapping ChevronLeft and ChevronRight) are icon-only and
need accessible labels; update the PaginationCircleButton instances that call
onPageChange (the left button using currentPage - 1 and the right button using
currentPage + 1) to include descriptive aria-label attributes (e.g., "Previous
page" and "Next page" or project-localized equivalents) and ensure the disabled
prop remains unchanged so screen readers announce state correctly.
In `@src/features/study/one-to-one/balance-game/ui/tag-autocomplete.tsx`:
- Around line 86-157: The Enter handler and handleAdd currently allow adding
empty/whitespace tags; update the logic to block additions when the trimmed
value length is less than minQueryLength: either validate inside handleAdd
(check const trimmed = tag.trim(); if (!trimmed || trimmed.length <
minQueryLength) return;) and call onAddTag(trimmed) only when valid, or apply
the same trimmed + minQueryLength check before calling handleAdd in the Enter
branch (and when adding from filteredSuggestions ensure the suggestion.name is
trimmed/non-empty). Ensure references: handleAdd, onAddTag, minQueryLength,
value, filteredSuggestions, activeIndex are used so the guard prevents adding
blank tags from Enter or suggestion paths.
- Around line 236-245: The remove-tag button inside the JSX mapping of
selectedTags (the selectedTags.map(...) block) lacks an explicit type and can
trigger form submit; update the button element rendered for each tag (the one
using key={tag} and onClick={() => onRemoveTag(tag)}) to include type="button"
so clicking a tag only invokes onRemoveTag and does not submit the surrounding
form.
In `@src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts`:
- Around line 17-58: The updater functions (setStatus, setSort, addTag,
removeTag) call triggerChange() inside React state updaters which can run twice
in Strict Mode; remove all triggerChange() calls from within those updater
callbacks and instead call options.onChange from a useEffect that watches the
derived filter state (statusFilter, sortMode, selectedTags) so onChange runs
only after state commits; keep triggerChange/ options?.onChange invocation logic
inside that useEffect and ensure the effect's dependency array includes set of
values representing the current filters.
In `@src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx`:
- Around line 49-51: The hardcoded label "1월 4주차 MVP 팀" should be replaced with
a dynamic string derived from the team prop (use team.weekDate,
team.weekStartDate or team.weekEndDate) inside the MVPTeamCard component
(mvp-team-card.tsx); update the span rendering (current static content) to
format the week/date consistently with the "이번 주" text used later (see reference
to the other label), ensuring the same locale/format logic is applied so both
places display matching week date text.
In `@src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx`:
- Around line 11-29: The component currently builds an iconPath and renders SVGs
via Next/Image (iconPath and Image in rank-badge.tsx); replace that with SVGR
imports and render the SVG React components directly: import the three icons as
components (e.g., GoldRank, SilverRank, BronzeRank), remove iconPath and Image
usage, and conditionally render the correct SVG component for rank 1/2/3 inside
the existing wrapper (keep the numeric fallback for rank > 3). Ensure the SVG
components receive the same className/size props used on the Image (e.g.,
object-contain and container sizing) so styling remains consistent.
In `@src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx`:
- Around line 16-60: The modal trigger is currently a non-focusable <div> passed
to UserProfileModal via the trigger prop; replace that wrapper with a focusable
button element (e.g., <button type="button">) in the UserProfileModal trigger so
keyboard users can open the modal, keep the same className and children
(RankBadge, UserAvatar, score) but ensure to set type="button", add an
accessible name/aria-label (use ranker.nickname or similar), and avoid nested
interactive controls inside the button (convert any inner anchors/buttons to
non-interactive spans if present); update references in this file around
UserProfileModal/trigger to reflect this change.
In `@src/features/study/one-to-one/history/ui/study-history-header.tsx`:
- Around line 59-83: The icon-only view mode buttons (the elements using
onViewModeChange('LIST') and onViewModeChange('CALENDAR') with the List and
CalendarIcon components) lack explicit type and accessible labels; add
type="button" to both buttons to prevent accidental form submits and add
descriptive aria-label attributes (e.g., "리스트 보기" and "달력 보기") so the hidden
<span> text still has an accessible name for screen readers when it’s visually
hidden.
In `@src/utils/voting-id.ts`:
- Around line 9-14: The decodeVotingId function currently rejects bigint results
from hashids.decode by only checking typeof id === 'number'; update
decodeVotingId to handle NumberLike values returned by hashids.decode: either
accept bigint IDs (handle typeof id === 'bigint' by returning Number(id) if safe
or returning the bigint directly) or explicitly document the intentional
limitation with a comment explaining why bigints are unsupported; reference the
decodeVotingId function and the decoded/id variables and the hashids.decode call
when applying the change.
🧹 Nitpick comments (39)
src/widgets/home/study-list-table.tsx (2)
9-9: 경로 별칭@/*사용 필요코딩 가이드라인에 따라
public/icons/Link.svg대신 경로 별칭을 사용해야 합니다.♻️ 수정 제안
-import LinkIcon from 'public/icons/Link.svg'; +import LinkIcon from '@/public/icons/Link.svg';As per coding guidelines: "Use path alias
@/* to reference src/* directory as configured in tsconfig.json for import statements"
123-135: Props 타입 설계 개선 고려
tutorialMode가true일 때studyDate는 사용되지 않지만 필수 prop으로 요구됩니다. 호출부에서 불필요한 값을 전달해야 하는 상황이 발생할 수 있습니다.♻️ 선택적 개선안: Discriminated Union 사용
type StudyListSectionProps = | { tutorialMode: true; studyDate?: never } | { tutorialMode?: false; studyDate: string }; export default function StudyListSection(props: StudyListSectionProps) { return props.tutorialMode ? ( <MockStudyListSection /> ) : ( <RealStudyListSection studyDate={props.studyDate} /> ); }src/components/section/my-participating-studies-section.tsx (2)
178-194:groupStudyId가 undefined인 경우에 대한 방어 코드 검토 필요.
studyId가0으로 fallback되면 잘못된 URL(/group-study/0)이 생성되고, GTM 이벤트에 부정확한 데이터가 전송될 수 있습니다. 또한 여러 스터디의groupStudyId가 undefined인 경우 React key 충돌이 발생할 수 있습니다.♻️ 유효한 studyId가 있는 항목만 필터링하는 방안
<div className="grid grid-cols-3 gap-300"> - {participatingStudies.map((study) => { - const studyId = study.basicInfo?.groupStudyId ?? 0; + {participatingStudies + .filter((study) => study.basicInfo?.groupStudyId != null) + .map((study) => { + const studyId = study.basicInfo!.groupStudyId; const title = study.simpleDetailInfo?.title ?? '';또는
participatingStudiesuseMemo에서 미리 필터링하는 것도 방법입니다.
148-159:handleStudyClick함수 메모이제이션 고려.현재 구현은 매 렌더링마다 새 함수 인스턴스가 생성됩니다.
StudyCard가React.memo로 감싸져 있다면,useCallback으로 최적화할 수 있습니다.♻️ useCallback 적용 예시
+import { useMemo, useCallback } from 'react'; - const handleStudyClick = (studyId: number, title: string) => { + const handleStudyClick = useCallback((studyId: number, title: string) => { sendGTMEvent({ event: classification === 'GROUP_STUDY' ? 'group_study_detail_view' : 'premium_study_detail_view', dl_timestamp: new Date().toISOString(), dl_member_id: hashValue(String(memberId)), dl_study_id: String(studyId), dl_study_title: title, }); - }; + }, [classification, memberId]);src/components/ui/toast.tsx (1)
35-38: Tailwind 클래스 정렬 규칙을 맞춰주세요.그룹 내 알파벳 정렬 규칙이 깨져 보여요. prettier-plugin-tailwindcss로 재정렬 부탁드립니다.
As per coding guidelines: Organize Tailwind CSS classes using prettier-plugin-tailwindcss for automatic class ordering and sort them alphabetically within groups.src/components/ui/action-pill-button.tsx (1)
4-44: variant/size 조합은 CVA로 관리하는 편이 일관됩니다. 동적 Tailwind 변형을 수동 맵으로 관리하면 확장·타입 안전성이 떨어집니다.As per coding guidelines: Use class composition utilities clsx, tailwind-merge, and class-variance-authority (CVA) for dynamic Tailwind class management.♻️ 제안 리팩터링
-import React from 'react'; -import { cn } from '@/components/ui/(shadcn)/lib/utils'; - -type ActionPillVariant = 'primary' | 'neutral' | 'ghost'; -type ActionPillSize = 'xs' | 'sm' | 'md'; - -interface ActionPillButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { - variant?: ActionPillVariant; - size?: ActionPillSize; - icon?: React.ReactNode; -} - -const VARIANT_CLASSES: Record<ActionPillVariant, string> = { - primary: - 'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover', - neutral: - 'bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover', - ghost: - 'bg-background-default text-text-subtle hover:bg-fill-neutral-subtle-hover', -}; - -const SIZE_CLASSES: Record<ActionPillSize, string> = { - xs: 'px-100 py-50 font-designer-11m', - sm: 'px-150 py-50 font-designer-12m', - md: 'px-200 py-100 font-designer-12m', -}; +import React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/components/ui/(shadcn)/lib/utils'; + +const actionPillButtonVariants = cva( + 'rounded-100 flex items-center gap-50 transition-colors', + { + variants: { + variant: { + primary: + 'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover', + neutral: + 'bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover', + ghost: 'bg-background-default text-text-subtle hover:bg-fill-neutral-subtle-hover', + }, + size: { + xs: 'px-100 py-50 font-designer-11m', + sm: 'px-150 py-50 font-designer-12m', + md: 'px-200 py-100 font-designer-12m', + }, + }, + defaultVariants: { + variant: 'ghost', + size: 'sm', + }, + }, +); + +interface ActionPillButtonProps + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof actionPillButtonVariants> { + icon?: React.ReactNode; +} @@ export default function ActionPillButton({ - variant = 'ghost', - size = 'sm', + variant, + size, icon, className, children, ...props }: ActionPillButtonProps) { return ( <button type="button" className={cn( - 'rounded-100 flex items-center gap-50 transition-colors', - VARIANT_CLASSES[variant], - SIZE_CLASSES[size], + actionPillButtonVariants({ variant, size }), className, )} {...props} >src/features/study/one-to-one/balance-game/ui/use-infinite-scroll.ts (1)
26-37:entries배열 접근 시 안전성 검사 추가 권장
entries[0]에 직접 접근하고 있는데, IntersectionObserver 콜백에서 배열이 비어있는 경우는 거의 없지만, 방어적 코딩을 위해 검사를 추가하는 것이 좋습니다.♻️ 제안된 수정
const observer = new IntersectionObserver( (entries) => { + const entry = entries[0]; + if (!entry) return; + if ( - entries[0].isIntersecting && + entry.isIntersecting && hasNextPage && !isFetchingNextPage && !isFetching ) { fetchNextPage().catch(() => { // 무한 스크롤 실패 시 무시 }); } }, { threshold }, );src/components/voting/voting-option-fields.tsx (2)
57-65: 접근성 향상을 위해aria-label추가 권장삭제 버튼에 시각적 아이콘만 있어서 스크린 리더 사용자가 버튼의 용도를 파악하기 어려울 수 있습니다.
♻️ 제안된 수정
{fields.length > 2 && ( <button type="button" onClick={() => remove(index)} + aria-label={`선택지 ${index + 1} 삭제`} className="rounded-100 text-text-subtle hover:bg-fill-critical-subtle-default hover:text-text-critical p-150 transition-colors" > <Trash2 className="h-4 w-4" /> </button> )}
74-83: 추가 버튼에도aria-label추가 권장일관성을 위해 추가 버튼에도 명시적인
aria-label을 추가하는 것이 좋습니다. 버튼 내 텍스트가 있지만, 아이콘과 텍스트를 포함한 전체 의미를 명확히 전달할 수 있습니다.♻️ 제안된 수정
<button type="button" onClick={() => append({ label: '' })} + aria-label="새 선택지 추가" className="rounded-100 border-border-brand font-designer-13b text-text-brand hover:bg-fill-brand-subtle-default mt-100 flex items-center gap-100 border border-dashed px-300 py-200 transition-colors" >src/features/study/one-to-one/history/ui/study-history-list-section.tsx (1)
17-25: 고정 너비 열이 작은 화면에서 레이아웃 문제를 일으킬 수 있음헤더 열들이 고정 픽셀 너비(
w-[150px],w-[120px]등)를 사용하고 있어, 작은 화면에서 콘텐츠가 잘리거나 레이아웃이 깨질 수 있습니다. 수평 스크롤을 허용하거나 반응형 처리를 고려해 보세요.♻️ 수평 스크롤 추가 제안
- <div className="bg-background-default rounded-200 border-border-subtle shadow-1 overflow-hidden border"> + <div className="bg-background-default rounded-200 border-border-subtle shadow-1 overflow-x-auto border"> + <div className="min-w-[900px]"> <div className="bg-background-alternative/80 border-border-subtlest font-designer-13b text-text-subtle flex gap-400 border-b px-400 py-250 tracking-wider uppercase"> ... </div> <div className="divide-border-subtlest divide-y"> ... </div> + </div> </div>src/features/study/one-to-one/history/ui/study-history-pagination.tsx (1)
21-35: 화살표 문자 대신 아이콘 사용 권장현재 화살표 문자(
←,→)를 사용하고 있는데, 다른 컴포넌트들이lucide-react아이콘을 사용하는 점을 고려하면ChevronLeft와ChevronRight아이콘으로 변경하는 것이 디자인 시스템의 일관성에 더 좋을 수 있습니다.♻️ 제안된 수정
+'use client'; + +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import React from 'react'; +import PaginationCircleButton from '@/features/study/one-to-one/ui/pagination-circle-button';<PaginationCircleButton onClick={() => onPageChange(Math.max(1, currentPage - 1))} disabled={currentPage === 1} > - ← + <ChevronLeft className="h-4 w-4" /> </PaginationCircleButton> ... <PaginationCircleButton onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))} disabled={currentPage === totalPages} > - → + <ChevronRight className="h-4 w-4" /> </PaginationCircleButton>src/components/ui/filters/sort-dropdown.tsx (1)
34-51: 접근성 개선이 필요합니다: 호버 전용 드롭다운은 키보드 및 터치 사용자에게 접근 불가합니다.현재
group-hover:block만 사용하여 드롭다운이 표시됩니다. 이는 다음 사용자에게 문제가 됩니다:
- 키보드 사용자 (Tab 네비게이션)
- 터치 디바이스 사용자
- 스크린 리더 사용자
권장 개선사항:
focus-within:block추가하여 키보드 접근성 확보- ARIA 속성 추가 (
aria-expanded,aria-haspopup,role="menu")- 옵션 버튼에
type="button"추가♻️ 접근성 개선 제안
- <div className="absolute top-full right-0 z-20 hidden w-[120px] pt-50 group-hover:block"> + <div className="absolute top-full right-0 z-20 hidden w-[120px] pt-50 group-hover:block group-focus-within:block"><button key={option.value} + type="button" onClick={() => onChange(option.value)} className="hover:bg-fill-neutral-subtle-hover font-designer-14r w-full px-200 py-150 text-left transition-colors" >src/features/study/one-to-one/balance-game/model/balance-game-keys.ts (1)
14-22:tags함수의 파라미터를 객체 형태로 변경하는 것을 고려해 주세요.다른 함수들(
list,searchSuggestionList)은 객체 파라미터를 사용하는데,tags만 위치 인자를 사용하고 있어 API 일관성이 떨어집니다.♻️ 리팩토링 제안
- tags: (query: string, limit: number, minLength: number, sort: string) => - [ - ...BALANCE_GAME_QUERY_KEYS.all, - 'tags', - query, - limit, - minLength, - sort, - ] as const, + tags: (params: { query: string; limit: number; minLength: number; sort: string }) => + [...BALANCE_GAME_QUERY_KEYS.all, 'tags', params] as const,src/features/study/one-to-one/balance-game/model/use-balance-game-search-suggestions-query.ts (1)
14-28: 검색어를 공통으로 트리밍해 캐시 분산을 줄여주세요.
enabled 계산은 trim 기준인데 queryKey/queryFn은 원문 query를 사용하고 있어 공백 유무로 캐시가 분산될 수 있습니다. trim 값을 공통 사용하면 의도와 일치합니다.💡 수정 제안
- const enabled = options?.enabled ?? query.trim().length >= minLength; + const trimmedQuery = query.trim(); + const enabled = options?.enabled ?? trimmedQuery.length >= minLength; return useQuery({ queryKey: BALANCE_GAME_QUERY_KEYS.searchSuggestionList({ - q: query, + q: trimmedQuery, size, minLength, scope, }), queryFn: () => - getBalanceGameSearchSuggestions({ q: query, size, minLength, scope }), + getBalanceGameSearchSuggestions({ q: trimmedQuery, size, minLength, scope }),src/hooks/use-scroll-to-home-content.ts (1)
49-75: 의존성 배열에 옵션/콜백을 포함해 최신 값이 반영되게 해주세요.
effect 내부에서 scrollToHomeContent와 options를 참조하지만 deps에 포함되지 않아 옵션 변경 시 오래된 값으로 동작할 수 있습니다.💡 수정 제안
const scrollToHomeContent = useScrollToHomeContent(options?.offset); const isFirst = useRef(true); + const effectDeps = [ + ...deps, + scrollToHomeContent, + options?.enabled, + options?.stabilize, + options?.stabilizeDurationMs, + ]; useEffect(() => { if (options?.enabled === false) return; if (isFirst.current) { isFirst.current = false; return; } scrollToHomeContent(); if (options?.stabilize) { stabilizeScrollAfterLayout( scrollToHomeContent, options?.stabilizeDurationMs ?? 800, ); } - }, deps); + }, effectDeps);src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts (2)
7-12: src 내부 import는@/별칭으로 통일해주세요.
상대경로 대신 tsconfig alias를 사용하면 경로 일관성과 리팩터링 안정성이 좋아집니다.As per coding guidelines, `**/*.{ts,tsx,js,jsx}`: Use path alias `@/`* to reference src/* directory as configured in tsconfig.json for import statements.💡 수정 제안
import { getBalanceGameComments, getBalanceGameDetail, getBalanceGameList, getBalanceGameTagSuggestions, -} from '../api/balance-game-api'; +} from '@/features/study/one-to-one/balance-game/api/balance-game-api';
14-27: 태그 순서로 인한 캐시 분산을 방지하도록 정규화 권장.
같은 태그 집합이라도 배열 순서가 다르면 queryKey가 달라져 캐시/요청이 분산될 수 있습니다. tags를 정규화해서 queryKey와 API 호출에 사용하세요.💡 수정 제안
) => { + const normalizedTags = tags?.filter(Boolean).sort(); return useInfiniteQuery({ - queryKey: BALANCE_GAME_QUERY_KEYS.list({ sort, status, tags, q }), + queryKey: BALANCE_GAME_QUERY_KEYS.list({ + sort, + status, + tags: normalizedTags, + q, + }), queryFn: ({ pageParam = 1 }) => - getBalanceGameList({ page: pageParam, size: 10, sort, status, tags, q }), + getBalanceGameList({ + page: pageParam, + size: 10, + sort, + status, + tags: normalizedTags, + q, + }),src/features/study/one-to-one/balance-game/ui/balance-game-filters-bar.tsx (1)
3-10: 로컬 UI 컴포넌트도@/별칭 경로로 통일해주세요.
src 내부 경로는 alias를 쓰는 규칙과 맞추는 편이 유지보수에 유리합니다.As per coding guidelines, `**/*.{ts,tsx,js,jsx}`: Use path alias `@/`* to reference src/* directory as configured in tsconfig.json for import statements.💡 수정 제안
import { ArrowUpDown, Search } from 'lucide-react'; import React from 'react'; import { cn } from '@/components/ui/(shadcn)/lib/utils'; import { useBalanceGameSearchSuggestionsQuery } from '@/features/study/one-to-one/balance-game/model/use-balance-game-search-suggestions-query'; import { useDebounce } from '@/hooks/use-debounce'; -import FilterPillButton from './filter-pill-button'; -import TagAutocomplete from './tag-autocomplete'; +import FilterPillButton from '@/features/study/one-to-one/balance-game/ui/filter-pill-button'; +import TagAutocomplete from '@/features/study/one-to-one/balance-game/ui/tag-autocomplete';src/components/voting/voting-tag-field.tsx (1)
32-36: 최대 태그 수 문구를 상수로 동기화하세요.
BALANCE_GAME_TAG_MAX_COUNT와 UI 문구가 분리돼 있어 값 변경 시 불일치가 생길 수 있습니다. 상수를 사용해 표시를 생성하는 편이 안전합니다.♻️ 제안 변경
- <span className="font-designer-12r text-text-subtle ml-100"> - (최대 3개) - </span> + <span className="font-designer-12r text-text-subtle ml-100"> + (최대 {BALANCE_GAME_TAG_MAX_COUNT}개) + </span>src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx (2)
30-32: 상대 경로 대신@/경로 별칭으로 통일해주세요.
src 하위 모듈은@/별칭 사용 규칙에 맞춰 정리하는 게 좋습니다.♻️ 제안 변경
-import BalanceGameFiltersBar from './balance-game-filters-bar'; -import { useBalanceGameFilters } from './use-balance-game-filters'; -import { useInfiniteScroll } from './use-infinite-scroll'; +import BalanceGameFiltersBar from '@/features/study/one-to-one/balance-game/ui/balance-game-filters-bar'; +import { useBalanceGameFilters } from '@/features/study/one-to-one/balance-game/ui/use-balance-game-filters'; +import { useInfiniteScroll } from '@/features/study/one-to-one/balance-game/ui/use-infinite-scroll';As per coding guidelines,
**/*.{ts,tsx,js,jsx}: Use path alias@/* to reference src/* directory as configured in tsconfig.json for import statements.
273-276: 리스트 키에 index를 섞지 않는 편이 좋습니다.
정렬/필터 변경 시 불필요한 리마운트가 발생할 수 있어 voting.id만 사용하는 것을 권장합니다.♻️ 제안 변경
- {visibleVotings.map((voting, index) => ( + {visibleVotings.map((voting) => ( <VotingCard - key={`${voting.id}-${index}`} + key={voting.id} voting={voting}src/app/(service)/home/page.tsx (1)
31-31: 빈 spacer div 대신 컨테이너 패딩으로 여백을 주는 게 낫습니다.
DOM 노이즈를 줄이고 접근성 관점에서도 더 깔끔합니다.♻️ 제안 변경
- <div className="mx-auto flex w-[1496px] flex-col gap-500 px-600 py-600"> + <div className="mx-auto flex w-[1496px] flex-col gap-500 px-600 py-600 pb-[400px]"> <Banner /> <FeedbackLink /> <StartStudyButton /> <HomeContent activeTab={activeTab} /> - <div className="h-[400px]" aria-hidden /> </div>src/api/client/axios.ts (1)
1-4: src 내부 import는@/별칭으로 통일해주세요.♻️ 제안 변경
-import { ApiError, isApiError } from './api-error'; -import { attachApiLogger } from './api-logger'; -import { getCookie, setCookie } from './cookie'; +import { ApiError, isApiError } from '@/api/client/api-error'; +import { attachApiLogger } from '@/api/client/api-logger'; +import { getCookie, setCookie } from '@/api/client/cookie';As per coding guidelines,
**/*.{ts,tsx,js,jsx}: Use path alias@/* to reference src/* directory as configured in tsconfig.json for import statements.src/components/discussion/comment-list.tsx (1)
89-97:any타입 캐스팅으로 인한 타입 안전성 저하
(comment.author as any).profileImage패턴은 TypeScript의 타입 체크를 우회합니다. 또한 중첩된 삼항 연산자로 인해 가독성이 떨어집니다.타입 가드 함수를 사용하거나,
DiscussionComment | VotingComment | BalanceGameComment유니온 타입에서profileImage속성을 명시적으로 처리하는 것이 좋습니다.♻️ 헬퍼 함수를 사용한 리팩토링 제안
+// 컴포넌트 외부에 헬퍼 함수 추가 +const getAuthorImage = (author: DiscussionComment['author'] | VotingComment['author'] | BalanceGameComment['author']): string | undefined => { + if ('avatar' in author) return author.avatar; + if ('profileImage' in author) { + const profileImage = author.profileImage; + if (typeof profileImage === 'string') return profileImage; + if (profileImage && typeof profileImage === 'object' && 'resizedImages' in profileImage) { + return profileImage.resizedImages?.[0]?.resizedImageUrl; + } + } + return undefined; +}; // 사용 시 - const authorImage = - 'avatar' in comment.author - ? comment.author.avatar - : 'profileImage' in comment.author - ? typeof (comment.author as any).profileImage === 'string' - ? (comment.author as any).profileImage - : (comment.author as any).profileImage?.resizedImages?.[0] - ?.resizedImageUrl - : undefined; + const authorImage = getAuthorImage(comment.author);src/features/study/one-to-one/history/ui/study-history-utils.ts (2)
1-1: 불필요한'use client'지시어이 파일은 React 훅이나 브라우저 API를 사용하지 않는 순수 유틸리티 함수입니다.
'use client'지시어는 필요하지 않으며, 서버 컴포넌트에서도 이 유틸리티를 사용할 수 있도록 제거하는 것이 좋습니다.♻️ 제안
-'use client'; - import type {
9-11: 날짜 포맷팅에date-fns사용 고려코드베이스의 다른 파일(예:
comment-list.tsx)에서 이미date-fns를 사용하고 있습니다. 일관성을 위해 수동 날짜 포맷팅 대신date-fns의format함수를 사용하는 것을 고려해 보세요.♻️ date-fns 사용 예시
import { format } from 'date-fns'; import { ko } from 'date-fns/locale'; const dateObj = new Date(data.scheduledAt); const date = format(dateObj, 'yyyy.MM.dd (E)', { locale: ko });src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section.tsx (1)
4-4: React 19에서 불필요한 importReact 19와 새로운 JSX Transform을 사용하면 JSX만을 위한
import React from 'react'는 필요하지 않습니다. 다만 코드베이스 전체의 일관성을 위해 유지해도 무방합니다.src/features/study/one-to-one/archive/model/use-archive-search-suggestions-query.ts (1)
13-24: 검색어 공백 정규화로 캐시 중복을 줄이는 것 검토하세요.
enabled는 trim 길이를 기준으로 판단하지만 실제 queryKey/요청은 원본 문자열을 사용해 공백 차이로 캐시가 분리될 수 있습니다.trim()을 한 번 계산해 일관되게 사용하면 호출/캐시 효율이 좋아집니다.♻️ 제안 변경
- const limit = options?.limit ?? 10; - const minLength = options?.minLength ?? 1; - const enabled = options?.enabled ?? query.trim().length >= minLength; + const limit = options?.limit ?? 10; + const minLength = options?.minLength ?? 1; + const normalizedQuery = query.trim(); + const enabled = options?.enabled ?? normalizedQuery.length >= minLength; return useQuery({ queryKey: ARCHIVE_QUERY_KEYS.searchSuggestionList({ - q: query, + q: normalizedQuery, limit, minLength, }), - queryFn: () => getArchiveSearchSuggestions({ q: query, limit, minLength }), + queryFn: () => + getArchiveSearchSuggestions({ q: normalizedQuery, limit, minLength }),src/features/study/one-to-one/schedule/ui/study-tutorial-controller.tsx (1)
6-10: src 경로는@/별칭으로 통일해 주세요.*현재 상대 경로 import가 있어 프로젝트 import 규칙과 어긋납니다.
@/별칭으로 통일해 주세요.As per coding guidelines, src 경로는 `@/`* 별칭으로 import해야 합니다.♻️ 제안 수정안
-import StudyCard from './study-card'; -import { - STUDY_TUTORIAL_STEPS, - getStudyTutorialScenario, -} from '../model/study-tutorial-steps'; +import StudyCard from '@/features/study/one-to-one/schedule/ui/study-card'; +import { + STUDY_TUTORIAL_STEPS, + getStudyTutorialScenario, +} from '@/features/study/one-to-one/schedule/model/study-tutorial-steps';src/features/study/one-to-one/archive/model/use-update-archive-mutation.ts (1)
12-57: 전반적으로 잘 구현된 optimistic update 패턴입니다.몇 가지 권장 개선 사항:
Line 43:
err와variables파라미터가 사용되지 않습니다. 언더스코어 접두사를 사용하거나 제거하는 것이 좋습니다.Line 50-56:
onSettled에서 invalidation을 수행하는 것은 괜찮지만, 학습된 패턴에 따르면onSuccess에서 수행하는 것이 더 일반적입니다. 현재 방식은 에러 발생 시에도 invalidation이 실행되므로 의도적이라면 문제없습니다.♻️ 미사용 파라미터 정리 제안
- onError: (err, variables, context) => { + onError: (_err, _variables, context) => {Based on learnings: "In TanStack Query useMutation hooks, call queryClient.invalidateQueries() in onSuccess callback to invalidate related queries"
src/features/study/one-to-one/archive/api/update-archive.ts (1)
16-26: API 함수 구현이 적절합니다.
axiosInstance를 올바르게 사용하고 있으며,data.data ?? data.content패턴으로 API 응답의 유연성을 잘 처리하고 있습니다.반환 타입을 명시적으로 지정하면 타입 안전성이 향상됩니다.
♻️ 명시적 반환 타입 추가 제안
export const updateArchive = async ( id: number, request: UpdateArchiveRequest, -) => { +): Promise<UpdateArchiveResponse | undefined> => {src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section.tsx (1)
29-33: 아이콘 렌더링 패턴을 간소화할 수 있습니다.IIFE 패턴 대신 직접 렌더링하면 코드가 더 간결해집니다. 동일한 패턴이 Line 54-58에도 반복됩니다.
♻️ 아이콘 렌더링 간소화 제안
<span className={TAB_CONFIG[rankingType].colorClass}> - {(() => { - const Icon = TAB_CONFIG[rankingType].icon; - - return <Icon className="h-4 w-4" />; - })()} + {React.createElement(TAB_CONFIG[rankingType].icon, { className: 'h-4 w-4' })} </span>또는 더 간단하게:
+ const IconComponent = TAB_CONFIG[rankingType].icon; <span className={TAB_CONFIG[rankingType].colorClass}> - {(() => { - const Icon = TAB_CONFIG[rankingType].icon; - - return <Icon className="h-4 w-4" />; - })()} + <IconComponent className="h-4 w-4" /> </span>src/features/study/one-to-one/archive/ui/use-archive-filters.ts (1)
28-48: 상호 배제 토글 로직이 잘 구현되어 있습니다.
toggleBookmarkedOnly와toggleMyOnly가 서로를 해제하는 상호 배제 패턴이 올바르게 구현되었습니다.다만, 필터 변경 시
currentPage를 1로 리셋하지 않으면 유효하지 않은 페이지 번호가 될 수 있습니다.♻️ 필터 변경 시 페이지 리셋 제안
const toggleBookmarkedOnly = () => { setShowBookmarkedOnly((prev) => { if (!prev) { setShowMyOnly(false); } return !prev; }); + setCurrentPage(1); triggerScroll(); }; const toggleMyOnly = () => { setShowMyOnly((prev) => { if (!prev) { setShowBookmarkedOnly(false); } return !prev; }); + setCurrentPage(1); triggerScroll(); };src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx (1)
7-14: 상대 경로 대신@/경로 별칭을 사용해 주세요.
동일한 src 하위 모듈이므로 alias로 통일하면 이동/리팩터링 시 안전합니다.As per coding guidelines, Use path alias `@/`* to reference src/* directory as configured in tsconfig.json for import statements.♻️ 제안 수정
-import { - addScoreLabel, - type RankingType, - type RankerWithLabel, -} from './hall-of-fame-constants'; -import HallOfFameHeader from './hall-of-fame-header'; -import HallOfFameMvpSection from './hall-of-fame-mvp-section'; -import HallOfFameRankerSection from './hall-of-fame-ranker-section'; +import { + addScoreLabel, + type RankingType, + type RankerWithLabel, +} from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants'; +import HallOfFameHeader from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-header'; +import HallOfFameMvpSection from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section'; +import HallOfFameRankerSection from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section';src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants.ts (1)
39-56:TAB_CONFIG의 unit을 재사용하면 중복/불일치 위험을 줄일 수 있습니다.♻️ 제안 수정
export const addScoreLabel = ( ranker: Ranker, type: RankingType, ): RankerWithLabel => { - let scoreLabel = ''; - - if (type === 'ATTENDANCE') { - scoreLabel = `${ranker.score}회`; - } else if (type === 'STUDY_LOG') { - scoreLabel = `${ranker.score}건`; - } else { - scoreLabel = `${ranker.score}℃`; - } - return { ...ranker, - scoreLabel, + scoreLabel: `${ranker.score}${TAB_CONFIG[type].unit}`, }; };src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx (1)
7-8: 상대 경로 대신@/경로 별칭을 사용해 주세요.As per coding guidelines, Use path alias `@/`* to reference src/* directory as configured in tsconfig.json for import statements.♻️ 제안 수정
-import type { RankerWithLabel } from './hall-of-fame-constants'; -import RankBadge from './rank-badge'; +import type { RankerWithLabel } from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants'; +import RankBadge from '@/features/study/one-to-one/hall-of-fame/ui/rank-badge';src/components/card/voting-card.tsx (1)
7-7: VoteTimer import도@/경로 별칭으로 통일해 주세요.As per coding guidelines, Use path alias `@/`* to reference src/* directory as configured in tsconfig.json for import statements.♻️ 제안 수정
-import VoteTimer from '../voting/vote-timer'; +import VoteTimer from '@/components/voting/vote-timer';src/features/study/one-to-one/archive/ui/archive-list.tsx (1)
29-36:canEditprop 타입 일관성 검토.
ArchiveListProps에서는canEdit이 필수(boolean)로 정의되어 있지만,LibraryRow에서는 선택적(boolean?)으로 정의되어 있습니다.ArchiveList에서 항상canEdit을 전달하므로LibraryRow에서도 필수로 변경하면 타입 일관성이 향상됩니다.♻️ 타입 일관성 개선 제안
}: { item: ArchiveItem; onLike: (e: React.MouseEvent, id: number) => void; onView: (item: ArchiveItem) => void; onBookmark: (e: React.MouseEvent, id: number) => void; onUpdate: (id: number, request: UpdateArchiveRequest) => void; - canEdit?: boolean; + canEdit: boolean; }) => {src/features/study/one-to-one/schedule/ui/today-study-card.tsx (1)
51-56: 중첩 삼항 연산자 가독성 개선 제안.
isInterviewee결정 로직이 중첩 삼항 연산자로 되어 있어 가독성이 다소 떨어집니다. 향후 유지보수를 위해 더 명확한 조건문으로 리팩토링을 고려해 보세요.♻️ 가독성 개선 제안
- const isInterviewee = - forcedRole === 'INTERVIEWER' - ? false - : forcedRole === 'INTERVIEWEE' - ? true - : memberId === resolvedStudyData.intervieweeId; + const isInterviewee = (() => { + if (forcedRole === 'INTERVIEWER') return false; + if (forcedRole === 'INTERVIEWEE') return true; + return memberId === resolvedStudyData.intervieweeId; + })();
| ``` | ||
| GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react | ||
| ``` |
There was a problem hiding this comment.
코드 블록에 언어 지정이 필요합니다. markdownlint(MD040) 경고가 발생합니다.
✏️ 제안 수정안
-```
-GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
-```
+```http
+GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
+```📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ``` | |
| GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react | |
| ``` |
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)
[warning] 44-44: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
In `@docs/balance-game-tag-filter-request.md` around lines 44 - 46, The fenced
code block containing the HTTP example "GET
/api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react"
needs a language specifier to satisfy markdownlint MD040; update that fenced
block (the triple-backtick block wrapping the GET request) to use ```http as the
opening fence and keep the closing ``` unchanged so the code block is properly
annotated.
| const stringifyData = (data: unknown) => { | ||
| if (data === undefined) return ''; | ||
|
|
||
| if (typeof data === 'string') return data; | ||
|
|
||
| try { | ||
| return JSON.stringify(data); | ||
| } catch { | ||
| return String(data); | ||
| } | ||
| }; | ||
|
|
||
| export const attachApiLogger = (instance: AxiosInstance, label: string) => { | ||
| if (!shouldLog) return; | ||
|
|
||
| instance.interceptors.request.use((config) => { | ||
| const method = (config.method || 'get').toUpperCase(); | ||
| const url = normalizeUrl(config); | ||
| const params = stringifyParams(config.params); | ||
|
|
||
| console.log( | ||
| `[API ${label}] ${method} ${url}${params ? ` params=${params}` : ''}`, | ||
| ); | ||
|
|
||
| return config; | ||
| }); | ||
|
|
||
| instance.interceptors.response.use( | ||
| (response) => { | ||
| const method = (response.config.method || 'get').toUpperCase(); | ||
| const url = normalizeUrl(response.config); | ||
| const data = stringifyData(response.data); | ||
|
|
||
| console.log(`[API ${label}] ${method} ${url} -> ${response.status}`); | ||
| if (data) { | ||
| console.log(`[API ${label}] response=${data}`); | ||
| } | ||
|
|
||
| return response; | ||
| }, | ||
| (error: AxiosError) => { | ||
| const config = error.config; | ||
| const method = config?.method?.toUpperCase() || 'UNKNOWN'; | ||
| const url = config ? normalizeUrl(config) : 'unknown'; | ||
| const status = error.response?.status; | ||
| const data = stringifyData(error.response?.data); | ||
|
|
||
| console.log( | ||
| `[API ${label}] ${method} ${url} -> ERROR${status ? ` ${status}` : ''}`, | ||
| ); | ||
| if (data) { | ||
| console.log(`[API ${label}] response=${data}`); | ||
| } |
There was a problem hiding this comment.
응답/파라미터 전체 로깅은 PII 노출 위험이 큽니다.
비프로덕션에서도 실제 사용자 데이터가 섞일 수 있으니 민감 키 마스킹 + 길이 제한 + 명시적 플래그로 제한하는 편이 안전합니다.
🛡️ 제안 수정
+const MAX_LOG_LENGTH = 2000;
+const REDACT_KEYS = new Set([
+ 'password',
+ 'token',
+ 'accessToken',
+ 'refreshToken',
+ 'authorization',
+]);
+
+const truncate = (value: string) =>
+ value.length > MAX_LOG_LENGTH ? `${value.slice(0, MAX_LOG_LENGTH)}…` : value;
+
const stringifyData = (data: unknown) => {
if (data === undefined) return '';
- if (typeof data === 'string') return data;
+ if (typeof data === 'string') return truncate(data);
try {
- return JSON.stringify(data);
+ return truncate(
+ JSON.stringify(data, (key, value) =>
+ REDACT_KEYS.has(key) ? '[REDACTED]' : value,
+ ),
+ );
} catch {
- return String(data);
+ return truncate(String(data));
}
};🤖 Prompt for AI Agents
In `@src/api/client/api-logger.ts` around lines 29 - 81, The request logger
currently prints full params and response bodies (see attachApiLogger,
stringifyData, stringifyParams, and the request/response interceptors); update
it to avoid PII by adding an explicit opt-in flag to attachApiLogger (e.g.,
enableSensitiveLogging) and only emit full payloads when that flag is true,
implement masking in stringifyData/stringifyParams to redact known sensitive
keys (password, token, ssn, email, authorization, etc.), and enforce a maximum
length/truncation for strings and arrays to avoid huge dumps; ensure the
interceptors use the new flag and masked/truncated output when logging params
and response bodies.
| <button className="rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover flex items-center gap-50 border px-200 py-150 whitespace-nowrap transition-colors"> | ||
| {icon} | ||
| {label} | ||
| </button> |
There was a problem hiding this comment.
버튼에 type="button" 속성을 추가하세요.
form 내부에서 사용될 경우 의도치 않은 form 제출이 발생할 수 있습니다.
🔧 수정 제안
- <button className="rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover flex items-center gap-50 border px-200 py-150 whitespace-nowrap transition-colors">
+ <button type="button" className="rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover flex items-center gap-50 border px-200 py-150 whitespace-nowrap transition-colors">📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <button className="rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover flex items-center gap-50 border px-200 py-150 whitespace-nowrap transition-colors"> | |
| {icon} | |
| {label} | |
| </button> | |
| <button type="button" className="rounded-100 bg-background-default border-border-subtle font-designer-14m text-text-default hover:bg-fill-neutral-subtle-hover flex items-center gap-50 border px-200 py-150 whitespace-nowrap transition-colors"> | |
| {icon} | |
| {label} | |
| </button> |
🤖 Prompt for AI Agents
In `@src/components/ui/filters/sort-dropdown.tsx` around lines 30 - 33, The button
in SortDropdown (the <button> element rendering {icon} and {label} in
src/components/ui/filters/sort-dropdown.tsx) lacks an explicit type, which can
cause unintended form submission; update the button element to include
type="button" to prevent default submit behavior when used inside forms, keeping
the existing classes and children unchanged.
| return ( | ||
| <button | ||
| key={option.value} | ||
| onClick={() => onChange(option.value)} | ||
| className={cn( | ||
| 'rounded-75 p-100 transition-colors', | ||
| isActive | ||
| ? 'bg-fill-neutral-default-default text-text-strong shadow-sm' | ||
| : 'text-text-subtlest hover:text-text-subtle', | ||
| )} | ||
| title={option.title} | ||
| > | ||
| {option.icon} | ||
| </button> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/components/ui/filters/view-mode-toggle.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 1673
🌐 Web query:
HTML button element default type behavior when omitted specification
💡 Result:
If a <button> omits the type attribute, the HTML Standard defines its missing-value default (and invalid-value default) as the “Auto” state [1]. In that Auto state, the button is treated as a submit button (i.e., activation submits the form) as long as it has no command/commandfor attributes and its parent is not a <select> element [1]. MDN summarizes this behavior as: the default is effectively type="submit" for buttons associated with a form [2].
Sources:
[1] WHATWG HTML Standard – button element / type missing-value default and “submit button” conditions: (html.spec.whatwg.org)
[2] MDN – <button> type default behavior: (developer.mozilla.org)
Citations:
- 1: https://html.spec.whatwg.org/dev/form-elements.html
- 2: https://developer.mozilla.org/en/docs/Web/HTML/Element/button?utm_source=openai
🌐 Web query:
ARIA toggle button aria-pressed accessibility requirements
💡 Result:
ARIA toggle button (aria-pressed) accessibility requirements
- Use a real
<button>when possible. If you must use a non-button element, addrole="button"and make it keyboard-focusable (e.g.,tabindex="0"). [1] - Provide an accessible name (visible text is usually enough; otherwise use
aria-label/aria-labelledby). [1] - Add
aria-pressedonly for toggle buttons. Its presence indicates “this is a toggle button,” and you must keep it in sync with the on/off state. [1][2] - Valid
aria-pressedvalues:true(pressed/on),false(not pressed/off),mixed(controls multiple items with mixed states). Ifaria-pressedis absent/undefined, it’s not a toggle button. [2][3] - Keyboard interaction: must toggle/activate on Space and Enter. (Native
<button>already does; custom elements must implement it.) [4] - Don’t change the toggle’s label when the state changes. Keep the label stable and let
aria-pressedconvey the state (e.g., “Mute”, not “Mute/Unmute”). [1][2] - If the control is unavailable, use
aria-disabled="true"(or nativedisabledon<button>). [1]
Minimal example
<button type="button" aria-pressed="false" id="mute">Mute</button>
<script>
const b = document.getElementById('mute');
b.addEventListener('click', () => {
b.setAttribute('aria-pressed',
b.getAttribute('aria-pressed') === 'true' ? 'false' : 'true'
);
});
</script>Sources: WAI-ARIA APG Button Pattern [1], MDN aria-pressed reference [2], WAI-ARIA aria-pressed state definition [3], APG Button example keyboard support [4].
아이콘 전용 버튼에 type/ARIA 속성을 추가하세요.
현재 버튼은 접근성 이름이 없고 토글 상태가 스크린리더에 전달되지 않습니다. type="button"을 명시하고, aria-pressed로 상태를 전달하며, aria-label로 접근성 이름을 제공하면 됩니다.
🛠️ 제안 수정
- <button
+ <button
key={option.value}
+ type="button"
+ aria-pressed={isActive}
+ aria-label={option.title ?? option.value}
onClick={() => onChange(option.value)}
className={cn(
'rounded-75 p-100 transition-colors',
isActive
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
title={option.title}
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return ( | |
| <button | |
| key={option.value} | |
| onClick={() => onChange(option.value)} | |
| className={cn( | |
| 'rounded-75 p-100 transition-colors', | |
| isActive | |
| ? 'bg-fill-neutral-default-default text-text-strong shadow-sm' | |
| : 'text-text-subtlest hover:text-text-subtle', | |
| )} | |
| title={option.title} | |
| > | |
| {option.icon} | |
| </button> | |
| return ( | |
| <button | |
| key={option.value} | |
| type="button" | |
| aria-pressed={isActive} | |
| aria-label={option.title ?? option.value} | |
| onClick={() => onChange(option.value)} | |
| className={cn( | |
| 'rounded-75 p-100 transition-colors', | |
| isActive | |
| ? 'bg-fill-neutral-default-default text-text-strong shadow-sm' | |
| : 'text-text-subtlest hover:text-text-subtle', | |
| )} | |
| title={option.title} | |
| > | |
| {option.icon} | |
| </button> |
🤖 Prompt for AI Agents
In `@src/components/ui/filters/view-mode-toggle.tsx` around lines 33 - 46, The
icon-only buttons in the ViewModeToggle rendering lack proper button type and
accessibility attributes; update the <button> created inside the map (the
element using key={option.value}, onClick={() => onChange(option.value)},
option.icon, etc.) to include type="button", aria-pressed={isActive} to expose
toggle state to assistive tech, and an accessible name via
aria-label={option.title} (or aria-label derived from option.title) so screen
readers announce the control.
| <Modal.Header className="border-border-default flex items-center justify-between border-b"> | ||
| <Modal.Title>{title}</Modal.Title> | ||
| <Modal.Close> | ||
| <XIcon /> | ||
| </Modal.Close> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/components/ui/modal-shell.tsx | head -50Repository: code-zero-to-one/study-platform-client
Length of output: 1444
🏁 Script executed:
rg "Modal\.Close" -A 3 -B 3 src/components/ui/Repository: code-zero-to-one/study-platform-client
Length of output: 750
🏁 Script executed:
rg "XIcon" -B 2 -A 2 src/components/ui/modal-shell.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 361
🏁 Script executed:
fd "modal" -e tsx -e ts src/ | head -20Repository: code-zero-to-one/study-platform-client
Length of output: 1108
🏁 Script executed:
find src/components/ui -name "modal*" -type fRepository: code-zero-to-one/study-platform-client
Length of output: 115
🏁 Script executed:
cat -n src/components/ui/modal.tsx | head -100Repository: code-zero-to-one/study-platform-client
Length of output: 141
🏁 Script executed:
rg "Modal.Close" -B 2 -A 3 src/components/modals/ | head -30Repository: code-zero-to-one/study-platform-client
Length of output: 2254
🏁 Script executed:
rg "from.*modal" src/components/ui/modal-shell.tsx -iRepository: code-zero-to-one/study-platform-client
Length of output: 128
🏁 Script executed:
rg "import.*Modal" src/components/modals/ | head -5Repository: code-zero-to-one/study-platform-client
Length of output: 567
🏁 Script executed:
fd "modal" src -e ts -e tsx | xargs rg "^import.*Modal" | head -10Repository: code-zero-to-one/study-platform-client
Length of output: 1135
🏁 Script executed:
rg "@radix-ui|@headlessui|react-aria" src/ --type json | head -5Repository: code-zero-to-one/study-platform-client
Length of output: 64
🏁 Script executed:
cat -n package.json | grep -A 20 -B 5 "dependencies"Repository: code-zero-to-one/study-platform-client
Length of output: 1285
🏁 Script executed:
find src -name "modal.ts*" -o -name "modal.ts" -o -name "modal.tsx"Repository: code-zero-to-one/study-platform-client
Length of output: 64
🏁 Script executed:
ls -la src/components/ui/ | grep modalRepository: code-zero-to-one/study-platform-client
Length of output: 203
🏁 Script executed:
rg "export.*Modal" src/components/ui/ -A 5Repository: code-zero-to-one/study-platform-client
Length of output: 721
🏁 Script executed:
ls -la src/components/ui/modal/Repository: code-zero-to-one/study-platform-client
Length of output: 254
🏁 Script executed:
cat -n src/components/ui/modal/index.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 5202
🏁 Script executed:
cat -n src/components/ui/modal/close.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 147
🏁 Script executed:
rg "aria-label" src/components/ | head -20Repository: code-zero-to-one/study-platform-client
Length of output: 573
🏁 Script executed:
rg "asChild" src/components/modals/ -B 2 -A 2 | head -30Repository: code-zero-to-one/study-platform-client
Length of output: 2066
아이콘 전용 닫기 버튼에 접근성 레이블 추가
스크린리더 사용자가 버튼의 목적을 인식할 수 없습니다. Modal.Close 컴포넌트는 기본 aria-label을 제공하지 않으므로 명시적으로 추가하세요.
제안 변경
- <Modal.Close>
- <XIcon />
- </Modal.Close>
+ <Modal.Close aria-label="닫기">
+ <XIcon aria-hidden="true" />
+ </Modal.Close>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <Modal.Header className="border-border-default flex items-center justify-between border-b"> | |
| <Modal.Title>{title}</Modal.Title> | |
| <Modal.Close> | |
| <XIcon /> | |
| </Modal.Close> | |
| <Modal.Header className="border-border-default flex items-center justify-between border-b"> | |
| <Modal.Title>{title}</Modal.Title> | |
| <Modal.Close aria-label="닫기"> | |
| <XIcon aria-hidden="true" /> | |
| </Modal.Close> |
🤖 Prompt for AI Agents
In `@src/components/ui/modal-shell.tsx` around lines 30 - 34, Modal.Close에 스크린리더용
레이블이 빠져 있어 접근성이 떨어집니다; Modal.Close (안의 XIcon)를 수정해 aria-label을 명시적으로 추가하세요 — 예:
Modal.Close에 aria-label 속성으로 "닫기" 또는 `Close ${title}` 형태의 설명을 전달하여 버튼 목적을 명확히 하며
Modal.Header, Modal.Title, Modal.Close 식별자를 찾아 해당 컴포넌트에 레이블을 추가하도록 변경하세요.
| <span className="bg-fill-warning-subtle-default text-text-warning font-designer-12b border-border-warning-subtle rounded-[9999px] border px-150 py-50"> | ||
| 1월 4주차 MVP 팀 | ||
| </span> |
There was a problem hiding this comment.
하드코딩된 날짜 텍스트를 동적으로 변경해야 합니다.
"1월 4주차 MVP 팀"이 하드코딩되어 있습니다. team prop에 weekDate, weekStartDate, weekEndDate 필드가 있으므로 이를 활용해야 합니다. 또한 Line 96의 "이번 주"와도 일관성이 맞지 않습니다.
🐛 동적 날짜 표시 제안
<span className="bg-fill-warning-subtle-default text-text-warning font-designer-12b border-border-warning-subtle rounded-[9999px] border px-150 py-50">
- 1월 4주차 MVP 팀
+ {team.weekDate} MVP 팀
</span>🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx` around lines
49 - 51, The hardcoded label "1월 4주차 MVP 팀" should be replaced with a dynamic
string derived from the team prop (use team.weekDate, team.weekStartDate or
team.weekEndDate) inside the MVPTeamCard component (mvp-team-card.tsx); update
the span rendering (current static content) to format the week/date consistently
with the "이번 주" text used later (see reference to the other label), ensuring the
same locale/format logic is applied so both places display matching week date
text.
| const iconPath = | ||
| rank === 1 | ||
| ? '/icons/gold-rank.svg' | ||
| : rank === 2 | ||
| ? '/icons/silver-rank.svg' | ||
| : '/icons/bronze-rank.svg'; | ||
|
|
||
| if (rank > 3) { | ||
| return ( | ||
| <div className="font-bold-h3 text-text-subtle w-[36px] text-center"> | ||
| {rank} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="relative h-[48px] w-[36px] md:h-[60px] md:w-[45px]"> | ||
| <Image src={iconPath} alt={`${rank}위`} fill className="object-contain" /> | ||
| </div> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check next.config.ts for SVGR configuration
find . -name "next.config.*" -type f | head -5Repository: code-zero-to-one/study-platform-client
Length of output: 98
🏁 Script executed:
# Search for existing SVG imports in the codebase to understand current patterns
rg "import.*\.svg" --type ts --type tsx --type js --type jsx | head -20Repository: code-zero-to-one/study-platform-client
Length of output: 109
🏁 Script executed:
# Check if public/icons directory exists with SVG files
find . -path "*/public/icons/*.svg" -type f | head -10Repository: code-zero-to-one/study-platform-client
Length of output: 383
🏁 Script executed:
# Read next.config.ts to check SVGR configuration
cat next.config.tsRepository: code-zero-to-one/study-platform-client
Length of output: 2453
🏁 Script executed:
# Search for SVG imports in the codebase
rg "\.svg" | grep -i "import" | head -20Repository: code-zero-to-one/study-platform-client
Length of output: 2087
🏁 Script executed:
# Read the rank-badge.tsx file to see the actual implementation
cat -n src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsxRepository: code-zero-to-one/study-platform-client
Length of output: 965
SVG 아이콘을 SVGR 컴포넌트로 import하여 사용해 주세요.
현재 코드는 Next/Image와 문자열 경로로 SVG를 렌더링하고 있습니다. 프로젝트 가이드라인에 따라 SVG 파일을 React 컴포넌트로 직접 import해야 합니다. 같은 아이콘들이 src/features/study/group/ui/group-study-member-item.tsx에서 올바르게 SVGR로 import되고 있으므로, 동일한 방식으로 적용하면 됩니다:
import Image from 'next/image';
+ import BronzeRankIcon from 'public/icons/bronze-rank.svg';
+ import GoldRankIcon from 'public/icons/gold-rank.svg';
+ import SilverRankIcon from 'public/icons/silver-rank.svg';
export default function RankBadge({ rank }: RankBadgeProps) {
- const iconPath = rank === 1 ? '/icons/gold-rank.svg' : rank === 2 ? '/icons/silver-rank.svg' : '/icons/bronze-rank.svg';
- <Image src={iconPath} alt={`${rank}위`} fill className="object-contain" />
+ const Icon = rank === 1 ? GoldRankIcon : rank === 2 ? SilverRankIcon : BronzeRankIcon;
+ <Icon />🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx` around lines 11
- 29, The component currently builds an iconPath and renders SVGs via Next/Image
(iconPath and Image in rank-badge.tsx); replace that with SVGR imports and
render the SVG React components directly: import the three icons as components
(e.g., GoldRank, SilverRank, BronzeRank), remove iconPath and Image usage, and
conditionally render the correct SVG component for rank 1/2/3 inside the
existing wrapper (keep the numeric fallback for rank > 3). Ensure the SVG
components receive the same className/size props used on the Image (e.g.,
object-contain and container sizing) so styling remains consistent.
| <UserProfileModal | ||
| memberId={ranker.userId} | ||
| trigger={ | ||
| <div className="group bg-background-default border-border-subtle rounded-150 shadow-1 hover:shadow-2 hover:border-border-default flex cursor-pointer items-center gap-300 border p-250 transition-all"> | ||
| <div className="flex w-[50px] shrink-0 items-center justify-center"> | ||
| <RankBadge rank={ranker.rank} /> | ||
| </div> | ||
|
|
||
| <UserAvatar | ||
| image={ | ||
| ranker.profileImage?.resizedImages?.[0]?.resizedImageUrl ?? | ||
| undefined | ||
| } | ||
| alt={ranker.nickname} | ||
| size={48} | ||
| /> | ||
|
|
||
| <div className="flex min-w-0 flex-1 flex-col"> | ||
| <div className="flex items-center gap-100"> | ||
| <span className="font-designer-16b text-text-strong group-hover:text-text-brand truncate transition-colors"> | ||
| {ranker.nickname} | ||
| </span> | ||
| {ranker.rank === 1 && ( | ||
| <Crown | ||
| className="text-text-warning h-3 w-3" | ||
| fill="currentColor" | ||
| /> | ||
| )} | ||
| </div> | ||
| <span className="font-designer-13r text-text-subtle truncate"> | ||
| {ranker.jobs && ranker.jobs.length > 0 | ||
| ? ranker.jobs | ||
| .map((job) => job.description || job.job || '') | ||
| .filter(Boolean) | ||
| .join(', ') | ||
| : ranker.major} | ||
| </span> | ||
| </div> | ||
|
|
||
| <div className="shrink-0 text-right"> | ||
| <div className="font-bold-h5 text-text-strong"> | ||
| {ranker.scoreLabel} | ||
| </div> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
모달 트리거가 div라 키보드 접근이 어렵습니다.
포커스 가능한 버튼 요소로 바꿔주세요.
🛠️ 제안 수정
- <div className="group bg-background-default border-border-subtle rounded-150 shadow-1 hover:shadow-2 hover:border-border-default flex cursor-pointer items-center gap-300 border p-250 transition-all">
+ <button
+ type="button"
+ className="group bg-background-default border-border-subtle rounded-150 shadow-1 hover:shadow-2 hover:border-border-default flex cursor-pointer items-center gap-300 border p-250 transition-all"
+ >
@@
- </div>
+ </button>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <UserProfileModal | |
| memberId={ranker.userId} | |
| trigger={ | |
| <div className="group bg-background-default border-border-subtle rounded-150 shadow-1 hover:shadow-2 hover:border-border-default flex cursor-pointer items-center gap-300 border p-250 transition-all"> | |
| <div className="flex w-[50px] shrink-0 items-center justify-center"> | |
| <RankBadge rank={ranker.rank} /> | |
| </div> | |
| <UserAvatar | |
| image={ | |
| ranker.profileImage?.resizedImages?.[0]?.resizedImageUrl ?? | |
| undefined | |
| } | |
| alt={ranker.nickname} | |
| size={48} | |
| /> | |
| <div className="flex min-w-0 flex-1 flex-col"> | |
| <div className="flex items-center gap-100"> | |
| <span className="font-designer-16b text-text-strong group-hover:text-text-brand truncate transition-colors"> | |
| {ranker.nickname} | |
| </span> | |
| {ranker.rank === 1 && ( | |
| <Crown | |
| className="text-text-warning h-3 w-3" | |
| fill="currentColor" | |
| /> | |
| )} | |
| </div> | |
| <span className="font-designer-13r text-text-subtle truncate"> | |
| {ranker.jobs && ranker.jobs.length > 0 | |
| ? ranker.jobs | |
| .map((job) => job.description || job.job || '') | |
| .filter(Boolean) | |
| .join(', ') | |
| : ranker.major} | |
| </span> | |
| </div> | |
| <div className="shrink-0 text-right"> | |
| <div className="font-bold-h5 text-text-strong"> | |
| {ranker.scoreLabel} | |
| </div> | |
| </div> | |
| </div> | |
| <UserProfileModal | |
| memberId={ranker.userId} | |
| trigger={ | |
| <button | |
| type="button" | |
| className="group bg-background-default border-border-subtle rounded-150 shadow-1 hover:shadow-2 hover:border-border-default flex cursor-pointer items-center gap-300 border p-250 transition-all" | |
| > | |
| <div className="flex w-[50px] shrink-0 items-center justify-center"> | |
| <RankBadge rank={ranker.rank} /> | |
| </div> | |
| <UserAvatar | |
| image={ | |
| ranker.profileImage?.resizedImages?.[0]?.resizedImageUrl ?? | |
| undefined | |
| } | |
| alt={ranker.nickname} | |
| size={48} | |
| /> | |
| <div className="flex min-w-0 flex-1 flex-col"> | |
| <div className="flex items-center gap-100"> | |
| <span className="font-designer-16b text-text-strong group-hover:text-text-brand truncate transition-colors"> | |
| {ranker.nickname} | |
| </span> | |
| {ranker.rank === 1 && ( | |
| <Crown | |
| className="text-text-warning h-3 w-3" | |
| fill="currentColor" | |
| /> | |
| )} | |
| </div> | |
| <span className="font-designer-13r text-text-subtle truncate"> | |
| {ranker.jobs && ranker.jobs.length > 0 | |
| ? ranker.jobs | |
| .map((job) => job.description || job.job || '') | |
| .filter(Boolean) | |
| .join(', ') | |
| : ranker.major} | |
| </span> | |
| </div> | |
| <div className="shrink-0 text-right"> | |
| <div className="font-bold-h5 text-text-strong"> | |
| {ranker.scoreLabel} | |
| </div> | |
| </div> | |
| </button> | |
| } | |
| /> |
🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx` around
lines 16 - 60, The modal trigger is currently a non-focusable <div> passed to
UserProfileModal via the trigger prop; replace that wrapper with a focusable
button element (e.g., <button type="button">) in the UserProfileModal trigger so
keyboard users can open the modal, keep the same className and children
(RankBadge, UserAvatar, score) but ensure to set type="button", add an
accessible name/aria-label (use ranker.nickname or similar), and avoid nested
interactive controls inside the button (convert any inner anchors/buttons to
non-interactive spans if present); update references in this file around
UserProfileModal/trigger to reflect this change.
| <div className="bg-background-default rounded-100 border-border-subtle flex shrink-0 border p-50"> | ||
| <button | ||
| onClick={() => onViewModeChange('LIST')} | ||
| className={cn( | ||
| 'rounded-75 flex items-center gap-50 p-100 transition-colors', | ||
| viewMode === 'LIST' | ||
| ? 'bg-fill-neutral-default-default text-text-strong shadow-sm' | ||
| : 'text-text-subtlest hover:text-text-subtle', | ||
| )} | ||
| > | ||
| <List className="h-4 w-4" /> | ||
| <span className="font-designer-13m hidden sm:inline">리스트</span> | ||
| </button> | ||
| <button | ||
| onClick={() => onViewModeChange('CALENDAR')} | ||
| className={cn( | ||
| 'rounded-75 flex items-center gap-50 p-100 transition-colors', | ||
| viewMode === 'CALENDAR' | ||
| ? 'bg-fill-neutral-default-default text-text-strong shadow-sm' | ||
| : 'text-text-subtlest hover:text-text-subtle', | ||
| )} | ||
| > | ||
| <CalendarIcon className="h-4 w-4" /> | ||
| <span className="font-designer-13m hidden sm:inline">달력</span> | ||
| </button> |
There was a problem hiding this comment.
아이콘-only 버튼에 접근성 레이블과 type 지정이 필요합니다.
모바일에서 텍스트가 숨겨져 버튼 이름이 사라지고, form 내부 사용 시 submit 동작 위험이 있습니다.
🛠️ 제안 수정
<button
+ type="button"
+ aria-label="리스트 보기"
onClick={() => onViewModeChange('LIST')}
className={cn(
'rounded-75 flex items-center gap-50 p-100 transition-colors',
viewMode === 'LIST'
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
>
@@
<button
+ type="button"
+ aria-label="달력 보기"
onClick={() => onViewModeChange('CALENDAR')}
className={cn(
'rounded-75 flex items-center gap-50 p-100 transition-colors',
viewMode === 'CALENDAR'
? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
: 'text-text-subtlest hover:text-text-subtle',
)}
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="bg-background-default rounded-100 border-border-subtle flex shrink-0 border p-50"> | |
| <button | |
| onClick={() => onViewModeChange('LIST')} | |
| className={cn( | |
| 'rounded-75 flex items-center gap-50 p-100 transition-colors', | |
| viewMode === 'LIST' | |
| ? 'bg-fill-neutral-default-default text-text-strong shadow-sm' | |
| : 'text-text-subtlest hover:text-text-subtle', | |
| )} | |
| > | |
| <List className="h-4 w-4" /> | |
| <span className="font-designer-13m hidden sm:inline">리스트</span> | |
| </button> | |
| <button | |
| onClick={() => onViewModeChange('CALENDAR')} | |
| className={cn( | |
| 'rounded-75 flex items-center gap-50 p-100 transition-colors', | |
| viewMode === 'CALENDAR' | |
| ? 'bg-fill-neutral-default-default text-text-strong shadow-sm' | |
| : 'text-text-subtlest hover:text-text-subtle', | |
| )} | |
| > | |
| <CalendarIcon className="h-4 w-4" /> | |
| <span className="font-designer-13m hidden sm:inline">달력</span> | |
| </button> | |
| <div className="bg-background-default rounded-100 border-border-subtle flex shrink-0 border p-50"> | |
| <button | |
| type="button" | |
| aria-label="리스트 보기" | |
| onClick={() => onViewModeChange('LIST')} | |
| className={cn( | |
| 'rounded-75 flex items-center gap-50 p-100 transition-colors', | |
| viewMode === 'LIST' | |
| ? 'bg-fill-neutral-default-default text-text-strong shadow-sm' | |
| : 'text-text-subtlest hover:text-text-subtle', | |
| )} | |
| > | |
| <List className="h-4 w-4" /> | |
| <span className="font-designer-13m hidden sm:inline">리스트</span> | |
| </button> | |
| <button | |
| type="button" | |
| aria-label="달력 보기" | |
| onClick={() => onViewModeChange('CALENDAR')} | |
| className={cn( | |
| 'rounded-75 flex items-center gap-50 p-100 transition-colors', | |
| viewMode === 'CALENDAR' | |
| ? 'bg-fill-neutral-default-default text-text-strong shadow-sm' | |
| : 'text-text-subtlest hover:text-text-subtle', | |
| )} | |
| > | |
| <CalendarIcon className="h-4 w-4" /> | |
| <span className="font-designer-13m hidden sm:inline">달력</span> | |
| </button> |
🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/history/ui/study-history-header.tsx` around
lines 59 - 83, The icon-only view mode buttons (the elements using
onViewModeChange('LIST') and onViewModeChange('CALENDAR') with the List and
CalendarIcon components) lack explicit type and accessible labels; add
type="button" to both buttons to prevent accidental form submits and add
descriptive aria-label attributes (e.g., "리스트 보기" and "달력 보기") so the hidden
<span> text still has an accessible name for screen readers when it’s visually
hidden.
| export const decodeVotingId = (hash: string) => { | ||
| const decoded = hashids.decode(hash); | ||
| const id = decoded[0]; | ||
|
|
||
| return typeof id === 'number' && Number.isFinite(id) ? id : null; | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
hashids library decode returns bigint large numbers JavaScript
💡 Result:
In modern hashids (v2.x), decode() returns NumberLike[]—i.e., an array whose elements can be number or bigint (not always plain JS numbers). If the id was encoded from a BigInt (or from values that require BigInt), you’ll get bigint back. [1]
What to do
const [x] = hashids.decode(id);
if (typeof x === "bigint") {
// keep it as BigInt
console.log(x.toString()); // exact decimal string
// or if you need hex:
console.log(x.toString(16));
} else {
// regular JS number
console.log(x);
}Common pitfall (looks like “wrong huge numbers”)
If you originally encoded an integer larger than Number.MAX_SAFE_INTEGER using a JS number, it may already be imprecise before Hashids ever sees it. In that case, switch to BigInt end-to-end:
const id = hashids.encode(9007199254740993n); // note the `n`
const [x] = hashids.decode(id); // BigInt backHashids explicitly supports encoding/decoding BigInt “the same way as ordinary numbers” when the environment supports it. [2]
If you need arbitrarily large integers but can’t/won’t use BigInt
Use a BigNumber-based fork such as hashids-bn (designed so numbers “aren’t limited”). [3]
Sources: hashids TypeScript definitions showing decode(): NumberLike[] and BigInt-related API notes [1], hashids README/Skypack docs on BigInt behavior [2], hashids-bn README summary [3].
[1] https://app.unpkg.com/hashids@2.3.0/files/esm/hashids.d.ts
[2] https://www.skypack.dev/view/hashids
[3] https://www.skypack.dev/view/hashids-bn
hashids.decode()의 bigint 반환값 처리 필요
hashids v2.x의 decode() 메서드는 NumberLike[] 타입을 반환하므로, 반환 배열의 원소는 number 또는 bigint일 수 있습니다. 큰 정수가 BigInt로 인코딩되었다면 디코딩 시에도 bigint가 반환됩니다.
현재 코드의 typeof id === 'number' 체크는 bigint를 명시적으로 거부합니다. 이 동작이 의도적인지 확인이 필요합니다:
- 큰 ID를 지원해야 한다면
bigint처리를 추가하세요. - 의도적인 제한이라면 왜
bigint를 지원하지 않는지 주석으로 명시하세요.
🤖 Prompt for AI Agents
In `@src/utils/voting-id.ts` around lines 9 - 14, The decodeVotingId function
currently rejects bigint results from hashids.decode by only checking typeof id
=== 'number'; update decodeVotingId to handle NumberLike values returned by
hashids.decode: either accept bigint IDs (handle typeof id === 'bigint' by
returning Number(id) if safe or returning the bigint directly) or explicitly
document the intentional limitation with a comment explaining why bigints are
unsupported; reference the decodeVotingId function and the decoded/id variables
and the hashids.decode call when applying the change.
🌱 연관된 이슈
☘️ 작업 내용
아카이브 ui 보완
밸런스 게임 > url 공유 기능 (투표 글 하나에 대해서 외부로 공유 가능하게끔)
밸런스게임에서 프사가 안보임 해결
나의 스터디목록 ui에 답변자 -> 지원자 이렇게 문구 바꿈.
밸런스게임에 태그 필터 기능 추가.
밸런스게임 글 작성에 기존 태그 목록 보여지도록 함.
답변자 -> 지원자.
fix: 다중태그 지원
fix: 검색 자동완성 및 ui 다듬기
fix: 복구
fix: ui디자인 변경 어드민 기능 삭제
feat: 스터디 튜토리얼 추가
refactor: 코드리팩토링
refactor: 코드 리팩토링
fix: ui 수정
🍀 참고사항
스크린샷 (선택)
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선 사항